iT邦幫忙

2022 iThome 鐵人賽

DAY 3
0
Modern Web

Three.js 學習日誌系列 第 3

Day2 - 從webGL的基礎開始?(二)

  • 分享至 

  • xImage
  •  

Day2 - 從webGL的基礎開始?(二)

這裡是「Three.js學習日誌」的第2篇,本篇的主旨是藉由描述一些簡單的webGL基礎,來做為引導three.js學習的鋪墊,這系列的文章假設讀者看得懂javascript,並且有Canvas 2D Context的相關知識

來寫一個webGL的hello world吧!

這邊我們要藉由用webGL畫一個三角形,來完成「寫一個webGL的hello world」這項任務~

1. 取得渲染環境

首先當然是要先建立canvas元素並取得webGL的渲染環境

index.html

<!-- 這邊給的這些 height:100%;width:100% 目的是要讓canvas跟視窗永遠等高等寬-->
<html style="height:100%">
    <body style="height:100%">
        <canvas style="height:100%;width:100%"></canvas>
    </body>
</html>

index.js

    const cvs = document.querySelector('canvas');
    const gl =  cvs.getContext('webgl');
    ...

2. 設定canvas的長寬與正確的螢幕解析度

接著我們要設定canvas的長寬,讓他可以以正確的螢幕解析度來顯示圖像。

index.js

...
//由於我們在html已經設定讓canvas跟視窗永遠等高等寬了,
//所以這邊就算強制的讓width和height以螢幕像素比的比率倍增,也不會導致canvas超出螢幕畫面
//而是形成一種把像素強制壓縮的效果,可以藉由這樣來生成正常的螢幕解析度
gl.canvas.width = window.devicePixelRatio * gl.canvas.clientWidth;
gl.canvas.height = window.devicePixelRatio * gl.canvas.clientHeight;
...

3. 設定webgl的座標映射

gl.viewport是一個webgl的特有方法,在webgl中,座標的分布比較特別,相較於我們常見的x軸``y軸都有正無限大到負無限大,webgl則是用+1~-1來表示,而gl.viewport的用意則是把+1~-1映射到視窗上指定的範圍。

...
// 這邊我們把整個視窗大小設定為映射範圍
// gl.viewport前兩個參數是映射範圍的x,y座標,後兩個則是映射範圍的長寬
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
...

4. 建立空白Shader腳本

雖然大多數的坊間翻譯都是把 Shader 翻譯為 「著色器」,但我其實一直覺得這個譯名翻得很不到位

Shader主要指的是定義圖形渲染流水線規則的算法腳本

就是我們上一篇提到過的「定義構成了圖像的頂點座標,接著賦予這些頂點指定的顏色」

一般會分成Vertex Shader(頂點著色器) 和 Fragment Shader(片段著色器),這兩者分別的職責就是「定義構成圖像的頂點其座標」& 「賦予這些頂點顏色」。

而這裡的gl.createShader 的用意則是要建立空白的shader腳本,並且可以傳入gl.VERTEX_SHADERgl.FRAGMENT_SHADER來決定到底是要產生哪一種空白腳本。

...
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
...

延伸閱讀: 著色器介紹 - 逍遙文工作室

5. 定義Shader腳本內容來源

產生完空白腳本之後則是要定義腳本內容所指向的來源,也就是腳本內容要放些什麼。

這邊shaderSource的第二個參數是要以字串型別傳入整個Shader的腳本內容。

一般狀況下,如果Shader的內容不複雜,
我們可以用ES6Template literals (就是反引號字串)
來撰寫腳本內容,但如果Shader的內容偏長,也可以額外寫成一份html格式的文件,然後用ajax的方式引入。

或是如果有用webpack,也可以搭配shader-loader 把腳本內容包裝成module

這種方法甚至還可以吃的到VSCode的語言highlight

這邊我們先用最簡單的Template literals來導入Shader腳本內容。

延伸閱讀:使用webpack導入shader的實際範例

...
const vertexShaderScript = `
//這個是shader宣告變數的方式,意思近似聲明有一個attribute類型的變數存在,並且他是一組vec4變數
//attribute是shader與外界(也就是js)溝通的一個橋樑,js可以透過attribute把值傳進來給shader運用
//vec4有點像js的陣列,但長度固定為4

attribute vec4 a_position;
 
// 所有著色器都有一個main方法

void main() {
 
  // gl_Position 是一個頂點著色器固有的變數(就像js在瀏覽器中也會有Math這樣的固有物件)
  // 這邊我們把他的值指向我們前面宣告的a_position

  gl_Position = a_position;
}
`
;
const fragmentShaderScript = `

// 這邊mediump是用來定義GPU計算浮點數時的精確度
// 片段著色器没有預設精確度,所以我們需要額外作設定
// 通常精確度會有三種值可以選(highp/mediump/lowp),但highp在某些系統下會有不支援的狀況,所以一般來說會選用mediump,代表“medium precision”(中等精度)

precision mediump float;
 
void main() {
  // gl_FragColor 是一個片段著色器固有的變數

  gl_FragColor = vec4(1, 0, 0.5, 1); 
  
  // 把所有的頂點都賦予“红紫色”的值,之所以是紅紫色,是因為前面三個數值(1,0,0.5) 換算成rgb,rgb通道都各乘以255,就會是 (255, 0, 127)
}
`;
gl.shaderSource(vertexShader, vertexShaderScript);
gl.shaderSource(fragmentShader, fragmentShaderScript);
...

6. 編譯Shader腳本

把兩種Shader腳本各自編譯成Binary Data,接下來的流程必需要用到。

...
gl.compileShader(vertexShader);
// 這邊是防呆用,假如Shader有寫錯,那就回報錯誤狀況
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
    console.warn(`vertex shader error!`, gl.getShaderInfoLog(vertexShader));
}

gl.compileShader(fragmentShader);
// 同上防呆用
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
  console.warn(`fragment shader error!`, gl.getShaderInfoLog(fragmentShader));
}
...

7. 建立WebGLProgram

我們可以把WebGLProgram視為前面提到的兩種Shader整併起來的完全體

也就是著色程序

...
function createProgram(gl, vertexShader, fragmentShader) {
  //建立空白的Program
  const program = gl.createProgram();
   //空白的Program連結上編譯好的shader
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  // 把program連接上webgl渲染環境
  gl.linkProgram(program);
  //防呆用,跟上一步驟類似
  gl.validateProgram(program);
  if (!gl.getProgramParameter(program, gl.VALIDATE_STATUS)) {
    console.warn(`validate program failed`, gl.getProgramInfoLog(program));
    gl.deleteProgram(program);
    return;
  }

  return program;
}

const program = createProgram(gl, vertexShader, fragmentShader);

...

8. 建立緩衝區,並把他綁定到webgl context 上

我猜應該也會有人跟我一樣,第一次看到緩衝區(Buffer)這個概念都會覺得很疑惑。

這裡其實要稍微運用一點想像力~

webgl Context 底下固定存在gl.ARRAY_BUFFERgl.ELEMENT_ARRAY_BUFFER 這兩個空槽位。

而我們需要指定一個新建立的buffer物件,把它放置到gl.ARRAY_BUFFER這個空槽位上。

buffer有點類似一個陣列,用來儲存頂點座標色彩,...etc.的資料

之所以需要有buffer,是因為我們接著需要一次性的向webgl Context填充大量的數據。

...
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
...

緩衝區示意

圖片來源: https://www.jianshu.com/p/f81deec335f1

9. 向buffer一次性填入所有頂點座標資料

這邊的bufferData的第一個參數指定的是 ---
我們前面提到的webgl Context底下固有的空槽位之一 --- gl.ARRAY_BUFFER

而不是去指向我們剛剛建立的Buffer

第二個參數是把一組頂點座標資料以Float32Array的格式傳進來。
最後第三個參數比較特別,他表示程序將如何使用儲存在Buffer中的數據,有三種值可選

  • gl.STATIC_DRAW:只會向緩衝區寫入一次數據
  • gl.STREAM_DRAW:只會向緩衝區寫入一次數據,然後繪製若干次
  • gl.DYNAMIC_DRAW:會向緩衝區多次寫入數據,並繪製多次

這一步的操作,最終導致了position這個陣列的資料被傳遞到了與gl.ARRAY_BUFFER綁定在一起的positionBuffer上。

...
const positions = [
  0, 0,
  0, 1.0,
  1.0, 0,
];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
...

10. 獲取Shader中的attribute變數儲存位置

這邊可以稍微注意一下,getAttribLocation必須要在已經執行過Buffer綁定的情況下才可以發動。

const positionAttributeLocation = gl.getAttribLocation(program, "a_position");

這邊所謂的位置有點抽象,但這邊我們其實可以透過我們前面寫到的Shader來做一個小實驗

  • 在正常情形下,下面的Shader會導致gl.getAttribLocation(program, "a_position")返回0這個值。
attribute vec4 a_position;

void main() {
  gl_Position = a_position;
}
  • 但在這個情況下,gl.getAttribLocation(program, "a_position")一樣返回0這個值,
    gl.getAttribLocation(program, "b_position")卻會返回-1。
attribute vec4 a_position;
attribute vec4 b_position;

void main() {
  gl_Position = a_position;
}
  • 接著再嘗試一種寫法,gl.getAttribLocation(program, "a_position")仍然會返回0這個值,
    gl.getAttribLocation(program, "b_position")這次卻會返回1。
attribute vec4 a_position;
attribute vec4 b_position;

void main() {
  gl_Position = a_position;
  gl_Position = b_position;
}

所以這裡可以推測,getAttribLocation返回的值會跟Shader中宣告attribute的順序,還有有沒有在main方法中調用有關。

有點像是Shader中每宣告一個變數,就會產生一個附帶序號的位置欄位,這樣的感覺。

11. 指定從Buffer中讀取數據的方式

我們在前面有提到,Buffer就像一個類陣列的物件,裡面儲存頂點座標色彩等資訊。
這邊要特別注意一點,Buffer儲存資料的方式其實是把所有資料統統混在一起的。
如果今天同時有頂點座標色彩儲存在Buffer裡面,內容就會像這樣:

// 偽code
//這邊只是以js的方式來說明,並不是真的要重新宣告一個positionBuffer
const positionBuffer = [
    '頂點一x座標',
    '頂點一y座標',
    '頂點一色彩r通道值',
    '頂點一色彩g通道值',
    '頂點一色彩b通道值',
    '頂點二x座標',
    '頂點二y座標',
    '頂點二色彩r通道值',
    '頂點二色彩g通道值',
    '頂點二色彩b通道值',
    ...
]

所以我們這邊需要去定義,每一個在Shader中宣告的attribute到底是要怎麼取用buffer中的資料。

但是因為我們這個hello world並沒有把每一點要設置的顏色對外開放。

而是在Shader中把所有的gl_FragColor都設定為紅紫色

所以其實Buffer中並不會有色彩的資訊,也就是像下面這樣。

// 偽code
//這邊只是以js的方式來說明,並不是真的要重新宣告一個positionBuffer
const positionBuffer = [
    '頂點一x座標',
    '頂點一y座標',
    '頂點二x座標',
    '頂點二y座標',
    ...
]

所以接著我們要使用gl.vertexAttribPointer來定義儲存在positionAttributeLocation的這個位置上的attribute,其從Buffer中取用資料的Pattern。

這邊有張圖,個人認為他很好的解釋了gl.vertexAttribPointer這個方法到底在幹麻。
示意

// 告訴屬性怎麼從positionBuffer中讀取數據 (ARRAY_BUFFER)
let size = 2;          // 每兩個單位數據算一組頂點座標
let type = gl.FLOAT;   // 每個單位的數據類型是32位浮點型
let normalize = false; // 不需要歸一化數據
let stride = 0;        // stride代表的是一個定點一共會需要多少位的數據
// 如果是有色彩數據參雜的形況,一個頂點就只會有座標資料+色彩資料,也就是5位數據,那就給5 * Float32Array.BYTES_PER_ELEMENT,
// 但是如果沒有座標以外的數據參雜,也就是每組頂點都是只含有座標的2位數據,則應該給0(這種情形被稱為tightly packed)
let offset = 0;        // 從Buffer的哪一個index作為起始點開始讀取
gl.vertexAttribPointer(
    positionAttributeLocation, size, type, normalize, stride, offset)

12. 開放attribute為可被取用,並指定著色程序

開放定義儲存在positionAttributeLocation的這個位置上的attribute為可被取用。
並且指定webgl Context使用前面建立的著色程序(Program)。

...
gl.enableVertexAttribArray(positionAttribLocation);
gl.useProgram(program);
...

13. 設定清除色並清除畫布,接著開始根據Buffer上的資料做繪製

這邊的gl.clearColor就有點像2D Context在做動畫的時候,每一幀都要清除畫布,不過這邊的做法是以特定的顏色填滿畫布,以達到清除的效果。

而下一行的gl.clear裡面傳入的gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT,這兩個是來自於底層的flag,分別用來代表"色彩緩衝區"和"深度緩衝區",概念上其實就跟我們前面提到的positionBuffer很接近。

但是相較於positionBuffer這個我們自己創造出來的緩衝區,這邊的gl.COLOR_BUFFER_BITgl.DEPTH_BUFFER_BIT是儲存在GPU中的原生資訊,可以想像GPU裡面其實存放著一個大陣列(當然這邊講陣列只是方便理解),裡面存滿一張canvas上所有使用到的色彩和深度資料。這邊一定會有人好奇深度又是一個什麼樣的概念,可以看這裡

在最後gl.drawArrays的部分,因為這個hello world要繪製的是三角形,所以gl.drawArrays的第一個參數得給gl.TRIANGLES

其餘可選值有:

  • gl.POINTS: 繪製多個點。
  • gl.LINES: 繪製一系列的線段。
  • gl.LINE_STRIP: 繪製一系列連接的線段。
  • gl.LINE_LOOP: 繪製多節線段,並且把最後一個線段連結回去繪製的原點,形成封閉線段。
  • gl.TRIANGLES: 繪製一系列的三角形。
  • gl.TRIANGLE_STRIP: 繪製一系列連接成帶狀的三角形。
  • gl.TRIANGLE_FAN: 繪製一系列連接成扇狀的三角形。

gl.drawArrays的第二個參數是offset,也就是要略過多少個頂點,注意是多少個頂點,而不是多少位數

gl.drawArrays的最後一個參數則是頂點數量,因為是三角形所以給3。

順帶一提,如果想繪製的圖形是長方形的話要給6,並且傳進去positionBuffer的陣列也必須要有6組頂點,因為webgl裡面沒有提供長方形的繪製選項,所以必須要用兩個三角形拼接起來,也就是3+3=6

...
gl.clearColor(1, 1, 1, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3 );

14. 搭拉,恭喜你完成了一個有史以來最長的hello world~

webgl hello world

Codepen傳送門:https://codepen.io/mizok/pen/KKRmdyB?editors=0010

15. 後續疑問補充

Q1. 為什麼只是指定了三個頂點的顏色就可以長出來一個完整的三角形,這邊不需要像2D Context一樣指定所有像素的顏色嗎?

對的,這邊確實只要指定三個頂點的顏色,就可以長出一個中間填滿色彩的三角形。
而若三個頂點顏色(vertex color)不同,則會生成一個內部具有漸層色彩的三角形。

Q2. 那如果是要畫一個很大的點要怎麼辦?

drawArray要改成代入gl.POINTS,並且vertex shader 的main方法裡面要加註這一行:gl_PointSize = 50.0;

Q3. 如果是要用webgl來做動畫,那哪些部份是要放在requestAnimationFrame的loop裡面的?

可以試試每一圈都重新執行gl.bufferDatagl.vertexAttribPointergl.clearColorgl.cleargl.drawArrays

實際的Animation案例可以參考這個repo

16. 如果還想要繼續研究webgl有沒有推薦的資源?

目前最完整且適合前端開發人員的webgl中文教程只有這個

不過個人其實覺得這篇教程還是多少對菜鳥不太友善,比方說一些很細微的地方缺乏完善的講解(比方說vertexAttritubPointer的 tightly packed狀況),所以還得多搭配自行google的能力。

其餘的話其實Stackoverflow上面就有很多有相關的討論,大陸的簡書上面也有不錯的教程(不過有點不完整)

這邊是只有列出我自己知道的。如果有研究的同好有其他的資源也歡迎提供QQ。

延伸閱讀


上一篇
Day1 - 從webGL的基礎開始?(一)
下一篇
Day3 - 進入Three.js的領域
系列文
Three.js 學習日誌31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
jerrythepotato
iT邦新手 3 級 ‧ 2024-02-07 16:08:40

才第二天就把最勸退的內容都講完了的感覺😂

我要留言

立即登入留言